garm/internal/templates/userdata/github_windows_userdata.tmpl
Gabriel Adrian Samfira 42cfd1b3c6 Add agent mode
This change adds a new "agent mode" to GARM. The agent enables GARM to
set up a persistent websocket connection between the garm server and the
runners it spawns. The goal is to be able to easier keep track of state,
even without subsequent webhooks from the forge.

The Agent will report via websockets when the runner is actually online,
when it started a job and when it finished a job.

Additionally, the agent allows us to enable optional remote shell between
the user and any runner that is spun up using agent mode. The remote shell
is multiplexed over the same persistent websocket connection the agent
sets up with the server (the agent never listens on a port).

Enablement has also been done in the web UI for this functionality.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
2026-02-08 00:27:47 +02:00

609 lines
No EOL
20 KiB
Cheetah

#ps1_sysnative
Param(
[Parameter(Mandatory=$false)]
[string]$Token="{{.CallbackToken}}"
)
$ErrorActionPreference="Stop"
function Start-ExecuteWithRetry {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)]
[ScriptBlock]$ScriptBlock,
[int]$MaxRetryCount=10,
[int]$RetryInterval=3,
[string]$RetryMessage,
[array]$ArgumentList=@()
)
PROCESS {
$currentErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$retryCount = 0
while ($true) {
try {
$res = Invoke-Command -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList
$ErrorActionPreference = $currentErrorActionPreference
return $res
} catch [System.Exception] {
$retryCount++
if ($_.Exception -is [System.Net.WebException]) {
$webResponse = $_.Exception.Response
# Skip retry on Error: 4XX (e.g. 401 Unauthorized, 404 Not Found etc.)
if ($webResponse -and $webResponse.StatusCode -ge 400 -and $webResponse.StatusCode -lt 500) {
# Skip retry on 4xx errors
Write-Output "Encountered non-retryable error (4xx): $($_.Exception.Message)"
$ErrorActionPreference = $currentErrorActionPreference
throw
}
}
if ($retryCount -gt $MaxRetryCount) {
$ErrorActionPreference = $currentErrorActionPreference
throw
} else {
if ($RetryMessage) {
Write-Output $RetryMessage
} elseif ($_) {
Write-Output $_
}
Start-Sleep -Seconds $RetryInterval
}
}
}
}
}
function Get-RandomString {
[CmdletBinding()]
Param(
[int]$Length=13
)
PROCESS {
if($Length -lt 6) {
$Length = 6
}
$special = @(44, 45, 46, 64)
$numeric = 48..57
$upper = 65..90
$lower = 97..122
$passwd = [System.Collections.Generic.List[object]](New-object "System.Collections.Generic.List[object]")
for($i=0; $i -lt $Length-4; $i++){
$c = get-random -input ($special + $numeric + $upper + $lower)
$passwd.Add([char]$c)
}
$passwd.Add([char](get-random -input $numeric))
$passwd.Add([char](get-random -input $special))
$passwd.Add([char](get-random -input $upper))
$passwd.Add([char](get-random -input $lower))
$Random = New-Object Random
return [string]::join("",($passwd|Sort-Object {$Random.Next()}))
}
}
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public class GrantSysPrivileges
{
[StructLayout(LayoutKind.Sequential)]
public struct LSA_UNICODE_STRING
{
public ushort Length;
public ushort MaximumLength;
public IntPtr Buffer;
}
[StructLayout(LayoutKind.Sequential)]
public struct LSA_OBJECT_ATTRIBUTES
{
public int Length;
public IntPtr RootDirectory;
public IntPtr ObjectName;
public uint Attributes;
public IntPtr SecurityDescriptor;
public IntPtr SecurityQualityOfService;
}
[DllImport("advapi32.dll", SetLastError=true)]
public static extern uint LsaOpenPolicy(
ref LSA_UNICODE_STRING SystemName,
ref LSA_OBJECT_ATTRIBUTES ObjectAttributes,
uint DesiredAccess,
out IntPtr PolicyHandle
);
[DllImport("advapi32.dll", SetLastError=true)]
public static extern uint LsaAddAccountRights(
IntPtr PolicyHandle,
IntPtr AccountSid,
LSA_UNICODE_STRING[] UserRights,
uint CountOfRights
);
[DllImport("advapi32.dll")]
public static extern uint LsaClose(IntPtr PolicyHandle);
[DllImport("advapi32.dll")]
public static extern uint LsaNtStatusToWinError(uint status);
public const uint POLICY_ALL_ACCESS = 0x00F0FFF;
public static uint GrantPrivilege(byte[] sid, string[] rights)
{
LSA_OBJECT_ATTRIBUTES loa = new LSA_OBJECT_ATTRIBUTES();
LSA_UNICODE_STRING systemName = new LSA_UNICODE_STRING();
IntPtr policyHandle;
uint result = LsaOpenPolicy(ref systemName, ref loa, POLICY_ALL_ACCESS, out policyHandle);
if (result != 0)
{
return LsaNtStatusToWinError(result);
}
LSA_UNICODE_STRING[] userRights = new LSA_UNICODE_STRING[rights.Length];
for (int i = 0; i < rights.Length; i++)
{
byte[] bytes = Encoding.Unicode.GetBytes(rights[i]);
IntPtr ptr = Marshal.AllocHGlobal(bytes.Length);
Marshal.Copy(bytes, 0, ptr, bytes.Length);
userRights[i].Buffer = ptr;
userRights[i].Length = (ushort)bytes.Length;
userRights[i].MaximumLength = (ushort)(bytes.Length);
}
IntPtr sidPtr = Marshal.AllocHGlobal(sid.Length);
Marshal.Copy(sid, 0, sidPtr, sid.Length);
result = LsaAddAccountRights(policyHandle, sidPtr, userRights, (uint)rights.Length);
LsaClose(policyHandle);
foreach (var right in userRights)
{
Marshal.FreeHGlobal(right.Buffer);
}
Marshal.FreeHGlobal(sidPtr);
return LsaNtStatusToWinError(result);
}
}
"@ -Language CSharp
function Invoke-FastWebRequest {
[CmdletBinding()]
Param(
[Parameter(Mandatory=$True,ValueFromPipeline=$true,Position=0)]
[System.Uri]$Uri,
[Parameter(Position=1)]
[string]$OutFile,
[Hashtable]$Headers=@{},
[switch]$SkipIntegrityCheck=$false
)
PROCESS
{
if(!([System.Management.Automation.PSTypeName]'System.Net.Http.HttpClient').Type)
{
$assembly = [System.Reflection.Assembly]::LoadWithPartialName("System.Net.Http")
}
if(!$OutFile) {
$OutFile = $Uri.PathAndQuery.Substring($Uri.PathAndQuery.LastIndexOf("/") + 1)
if(!$OutFile) {
throw "The ""OutFile"" parameter needs to be specified"
}
}
$fragment = $Uri.Fragment.Trim('#')
if ($fragment) {
$details = $fragment.Split("=")
$algorithm = $details[0]
$hash = $details[1]
}
if (!$SkipIntegrityCheck -and $fragment -and (Test-Path $OutFile)) {
try {
return (Test-FileIntegrity -File $OutFile -Algorithm $algorithm -ExpectedHash $hash)
} catch {
Remove-Item $OutFile
}
}
$client = new-object System.Net.Http.HttpClient
foreach ($k in $Headers.Keys){
$client.DefaultRequestHeaders.Add($k, $Headers[$k])
}
$task = $client.GetStreamAsync($Uri)
$response = $task.Result
if($task.IsFaulted) {
$msg = "Request for URL '{0}' is faulted. Task status: {1}." -f @($Uri, $task.Status)
if($task.Exception) {
$msg += "Exception details: {0}" -f @($task.Exception)
}
Throw $msg
}
$outStream = New-Object IO.FileStream $OutFile, Create, Write, None
try {
$totRead = 0
$buffer = New-Object Byte[] 1MB
while (($read = $response.Read($buffer, 0, $buffer.Length)) -gt 0) {
$totRead += $read
$outStream.Write($buffer, 0, $read);
}
}
finally {
$outStream.Close()
}
if(!$SkipIntegrityCheck -and $fragment) {
Test-FileIntegrity -File $OutFile -Algorithm $algorithm -ExpectedHash $hash
}
}
}
function Import-Certificate() {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
$CertificateData,
[parameter(Mandatory=$false)]
[System.Security.Cryptography.X509Certificates.StoreLocation]$StoreLocation="LocalMachine",
[parameter(Mandatory=$false)]
[System.Security.Cryptography.X509Certificates.StoreName]$StoreName="TrustedPublisher"
)
PROCESS
{
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store(
$StoreName, $StoreLocation)
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($CertificateData)
$store.Add($cert)
}
}
function Invoke-APICall() {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
[object]$Payload,
[parameter(Mandatory=$true)]
[string]$CallbackURL
)
PROCESS{
Invoke-WebRequest -UseBasicParsing -Method Post -Headers @{"Accept"="application/json"; "Authorization"="Bearer $Token"} -Uri $CallbackURL -Body (ConvertTo-Json $Payload) | Out-Null
}
}
function Update-GarmStatus() {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
[string]$Message,
[parameter(Mandatory=$false)]
[int64]$AgentID=0,
[parameter(Mandatory=$false)]
[string]$Status="installing",
[parameter(Mandatory=$true)]
[string]$CallbackURL
)
PROCESS{
$body = @{
"status"=$Status
"message"=$Message
}
if ($AgentID -ne 0) {
$body["agent_id"] = $AgentID
}
Invoke-APICall -Payload $body -CallbackURL $CallbackURL | Out-Null
}
}
function Invoke-GarmSuccess() {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
[string]$Message,
[parameter(Mandatory=$true)]
[int64]$AgentID,
[parameter(Mandatory=$true)]
[string]$CallbackURL
)
PROCESS{
Update-GarmStatus -Message $Message -AgentID $AgentID -CallbackURL $CallbackURL -Status "idle" | Out-Null
}
}
function Invoke-GarmFailure() {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
[string]$Message,
[parameter(Mandatory=$true)]
[string]$CallbackURL
)
PROCESS{
Update-GarmStatus -Message $Message -CallbackURL $CallbackURL -Status "failed" | Out-Null
Throw $Message
}
}
function Set-SystemInfo {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
[string]$CallbackURL,
[parameter(Mandatory=$true)]
[string]$RunnerDir,
[parameter(Mandatory=$true)]
[string]$BearerToken
)
# Construct the path to the .runner file
$agentInfoFile = Join-Path $RunnerDir ".runner"
# Read and parse the JSON content from the .runner file
$agentInfo = ConvertFrom-Json (Get-Content -Raw -Path $agentInfoFile)
$AgentId = $agentInfo.agent_id
# Retrieve OS information
$osInfo = Get-WmiObject -Class Win32_OperatingSystem
$osName = $osInfo.Caption
$osVersion = $osInfo.Version
# Strip status from the callback URL
if ($CallbackUrl -match '^(.*)/status(/)?$') {
$CallbackUrl = $matches[1]
}
$SysInfoUrl = "$CallbackUrl/system-info/"
$Payload = @{
os_name = $OSName
os_version = $OSVersion
agent_id = $AgentId
} | ConvertTo-Json
# Send the POST request
try {
Invoke-RestMethod -Uri $SysInfoUrl -Method Post -Body $Payload -ContentType 'application/json' -Headers @{ 'Authorization' = "Bearer $BearerToken" } -ErrorAction Stop
} catch {
Write-Output "Failed to send the system information."
}
}
$CallbackURL="{{.CallbackURL}}"
if (!($CallbackURL -match "^(.*)/status(/)?$")) {
$CallbackURL = "$CallbackURL/status"
}
$GHRunnerGroup = "{{.GitHubRunnerGroup}}"
try {
$instanceMetadata = (wget -UseBasicParsing -Headers @{"Accept"="application/json"; "Authorization"="Bearer $Token"} -Uri {{.MetadataURL}}/runner-metadata)
$metadata = ConvertFrom-Json $instanceMetadata.Content
} catch {
Invoke-GarmFailure -Message "failed to get runner metadata: $_" -CallbackURL "{{.CallbackURL}}" | Out-Null
}
function Get-IsAgentMode {
return ($metadata.agent_mode -eq $true)
}
function Get-AgentURL {
$url = $metadata.metadata_access.agent_url
if (!$url) {
Throw("missing agent URL")
}
return $url
}
function Get-AgentToken {
$token = $metadata.agent_token
if (!$token) {
Throw("missing agent Token")
}
return $token
}
function Get-AgentDownloadURL {
$url = $metadata.agent_tools.download_url
if (!$url) {
Throw("missing agent download URL")
}
return $url
}
function Get-AgentShellEnabled {
$shellEnabled = $metadata.agent_shell_enabled
if ($shellEnabled) {return "true"}
return "false"
}
function Install-GarmAgent {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
[System.Management.Automation.PSCredential]$pscreds
)
Update-GarmStatus -Message "Agent mode is enabled" -CallbackURL $CallbackURL | Out-Null
$agentDir = "C:\garm-agent"
mkdir -Force $agentDir
$agentURL = Get-AgentURL
$agentDownloadURL = Get-AgentDownloadURL
$agentToken = Get-AgentToken
$agentDownloadHeaders=@{
"Authorization"="Bearer $Token"
}
$shellEnabled = Get-AgentShellEnabled
Set-Content "$agentDir\garm-agent.toml" @"
server_url = "$agentURL"
log_file = "C:/garm-agent/garm-agent.log"
shell = "cmd.exe"
enable_shell = $shellEnabled
work_dir = "C:/actions-runner/"
token = "$agentToken"
runner_cmdline = ["C:\\Windows\\system32\\cmd.exe", "/C", "C:\\actions-runner\\run.cmd"]
state_db_path = "C:/garm-agent/agent-state.db"
"@
Update-GarmStatus -Message "Downloading agent from: $agentDownloadURL" -CallbackURL $CallbackURL | Out-Null
Start-ExecuteWithRetry -ScriptBlock {
Invoke-FastWebRequest -Headers $agentDownloadHeaders -Uri "$agentDownloadURL" -OutFile $agentDir\garm-agent.exe
} -MaxRetryCount 5 -RetryInterval 5 -RetryMessage "Retrying download of runner..."
try {
New-Service -Name garm-agent -BinaryPathName "$agentDir\garm-agent.exe daemon --config $agentDir\garm-agent.toml" -DisplayName "garm-agent" -Description "GARM agent" -StartupType Automatic -Credential $pscreds
} catch {
Invoke-GarmFailure -CallbackURL $CallbackURL -Message "failed to set up garm agent $_"
}
Start-Service garm-agent
}
function Install-Runner() {
if ($Token.Length -eq 0) {
Throw "missing callback authentication token"
}
try {
$MetadataURL="{{.MetadataURL}}"
$DownloadURL="{{.DownloadURL}}"
if($MetadataURL -eq ""){
Throw "missing metadata URL"
}
# Create user with administrator rights to run service as
$userPasswd = Get-RandomString -Length 10
$secPasswd = ConvertTo-SecureString "$userPasswd" -AsPlainText -Force
$userName = "runner"
$user = Get-LocalUser -Name $userName -ErrorAction SilentlyContinue
if (-not $user) {
New-LocalUser -Name $userName -Password $secPasswd -PasswordNeverExpires -UserMayNotChangePassword
} else {
Set-LocalUser -PasswordNeverExpires $true -Name $userName -Password $secPasswd
}
$pscreds = New-Object System.Management.Automation.PSCredential (".\$userName", $secPasswd)
$hasUser = Get-LocalGroupMember -SID S-1-5-32-544 -Member $userName -ErrorAction SilentlyContinue
if (-not $hasUser){
Add-LocalGroupMember -SID S-1-5-32-544 -Member $userName
}
$ntAcct = New-Object System.Security.Principal.NTAccount($userName)
$sid = $ntAcct.Translate([System.Security.Principal.SecurityIdentifier])
$sidBytes = New-Object byte[] ($sid.BinaryLength)
$sid.GetBinaryForm($sidBytes, 0)
$result = [GrantSysPrivileges]::GrantPrivilege($sidBytes, ("SeBatchLogonRight", "SeServiceLogonRight"))
if ($result -ne 0) {
Throw "Failed to grant privileges"
}
$bundle = wget -UseBasicParsing -Headers @{"Accept"="application/json"; "Authorization"="Bearer $Token"} -Uri $MetadataURL/system/cert-bundle
$converted = ConvertFrom-Json $bundle
foreach ($i in $converted.root_certificates.psobject.Properties){
$data = [System.Convert]::FromBase64String($i.Value)
Import-Certificate -CertificateData $data -StoreName Root -StoreLocation LocalMachine
}
$runnerDir = "C:\actions-runner"
# Check if a cached runner is available
if (-not (Test-Path $runnerDir)) {
# No cached runner found, proceed to download and extract
Update-GarmStatus -CallbackURL $CallbackURL -Message "downloading tools from {{ .DownloadURL }}"
$downloadToken="{{.TempDownloadToken}}"
$DownloadTokenHeaders=@{}
if ($downloadToken.Length -gt 0) {
$DownloadTokenHeaders=@{
"Authorization"="Bearer $downloadToken"
}
}
$downloadPath = Join-Path $env:TMP "{{ .FileName }}"
Start-ExecuteWithRetry -ScriptBlock {
Invoke-FastWebRequest -Uri "{{ .DownloadURL }}" -OutFile $downloadPath -Headers $DownloadTokenHeaders
} -MaxRetryCount 5 -RetryInterval 5 -RetryMessage "Retrying download of runner..."
mkdir $runnerDir
Update-GarmStatus -CallbackURL $CallbackURL -Message "extracting runner"
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory($downloadPath, "$runnerDir")
} else {
Update-GarmStatus -CallbackURL $CallbackURL -Message "using cached runner found at $runnerDir"
}
# Ensure runner has full access to actions-runner folder
$runnerACL = Get-Acl $runnerDir
$runnerACL.SetAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
$userName, "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow"
)))
Set-Acl -Path $runnerDir -AclObject $runnerAcl
Update-GarmStatus -CallbackURL $CallbackURL -Message "configuring and starting runner"
cd $runnerDir
{{- if .UseJITConfig }}
Update-GarmStatus -CallbackURL $CallbackURL -Message "downloading JIT credentials"
wget -UseBasicParsing -Headers @{"Accept"="application/json"; "Authorization"="Bearer $Token"} -Uri $MetadataURL/credentials/runner -OutFile (Join-Path $runnerDir ".runner")
wget -UseBasicParsing -Headers @{"Accept"="application/json"; "Authorization"="Bearer $Token"} -Uri $MetadataURL/credentials/credentials -OutFile (Join-Path $runnerDir ".credentials")
Add-Type -AssemblyName System.Security
$rsaData = (wget -UseBasicParsing -Headers @{"Accept"="application/json"; "Authorization"="Bearer $Token"} -Uri $MetadataURL/credentials/credentials_rsaparams)
$encodedBytes = [System.Text.Encoding]::UTF8.GetBytes($rsaData)
$protectedBytes = [Security.Cryptography.ProtectedData]::Protect( $encodedBytes, $null, [Security.Cryptography.DataProtectionScope]::LocalMachine )
[System.IO.File]::WriteAllBytes((Join-Path $runnerDir ".credentials_rsaparams"), $protectedBytes)
$serviceNameFile = (Join-Path $runnerDir ".service")
wget -UseBasicParsing -Headers @{"Accept"="application/json"; "Authorization"="Bearer $Token"} -Uri $MetadataURL/system/service-name -OutFile $serviceNameFile
Update-GarmStatus -CallbackURL $CallbackURL -Message "Creating system service"
$SVC_NAME=(gc -raw $serviceNameFile)
if (!(Get-IsAgentMode)) {
New-Service -Name "$SVC_NAME" -BinaryPathName "C:\actions-runner\bin\RunnerService.exe" -DisplayName "$SVC_NAME" -Description "GitHub Actions Runner ($SVC_NAME)" -StartupType Automatic -Credential $pscreds
Start-Service "$SVC_NAME"
} else {
Install-GarmAgent $pscreds
}
Set-SystemInfo -CallbackURL $CallbackURL -RunnerDir $runnerDir -BearerToken $Token
Update-GarmStatus -Message "runner successfully installed" -CallbackURL $CallbackURL -Status "idle" | Out-Null
{{- else }}
$GithubRegistrationToken = Start-ExecuteWithRetry -ScriptBlock {
Invoke-WebRequest -UseBasicParsing -Headers @{"Accept"="application/json"; "Authorization"="Bearer $Token"} -Uri $MetadataURL/runner-registration-token/
} -MaxRetryCount 5 -RetryInterval 5 -RetryMessage "Retrying download of GitHub registration token..."
$argList = @{
"--unattended" = $null;
"--url" = "{{ .RepoURL }}";
"--token" = $GithubRegistrationToken;
"--name" = "{{ .RunnerName }}";
"--labels" = "{{ .RunnerLabels }}"
"--no-default-labels" = $null;
"--ephemeral" = $null;
}
{{- if .GitHubRunnerGroup }}
$argList["--runnergroup"] = {{.GitHubRunnerGroup}}
{{- end }}
if (!(Get-IsAgentMode)) {
$argList["--runasservice"] = $null
$argList["--windowslogonaccount"] = "$userName"
$argList["--windowslogonpassword"] = $userPasswd
}
./config.cmd @argList
if ($LASTEXITCODE) {
Throw "Failed to configure runner. Err code $LASTEXITCODE"
}
$agentInfoFile = Join-Path $runnerDir ".runner"
$agentInfo = ConvertFrom-Json (gc -raw $agentInfoFile)
Set-SystemInfo -CallbackURL $CallbackURL -RunnerDir $runnerDir -BearerToken $Token
if ((Get-IsAgentMode)) {
Install-GarmAgent $pscreds
}
Invoke-GarmSuccess -CallbackURL $CallbackURL -Message "runner successfully installed" -AgentID $agentInfo.agentId
{{- end }}
} catch {
Invoke-GarmFailure -CallbackURL $CallbackURL -Message $_
}
}
Install-Runner